Skip to content

fix(storage): fail export when current pool is empty#320

Merged
ndycode merged 10 commits intomainfrom
fix/export-empty-storage-state
Mar 23, 2026
Merged

fix(storage): fail export when current pool is empty#320
ndycode merged 10 commits intomainfrom
fix/export-empty-storage-state

Conversation

@ndycode
Copy link
Owner

@ndycode ndycode commented Mar 23, 2026

Summary

  • keep account export read-only so empty current storage does not revive legacy, WAL, or reset-cleared state
  • read live current storage instead of a stale transaction snapshot when export runs inside a different storage transaction
  • harden export fallback when the current file reappears mid-read and is immediately locked

What Changed

  • loadAccountsForExport() stays side-effect free and reset-marker aware
  • migrateLegacyProjectStorageIfNeeded({ commit: false }) is only used for in-memory export fallback and never writes or unlinks files on that path
  • export-mode TOCTOU re-reads now honor a reappeared live file and rethrow EBUSY / EPERM / EAGAIN lock races instead of collapsing them into an empty result
  • exportAccountsSnapshot() still reuses the transaction snapshot only when it matches the current storage path
  • focused regressions cover empty-current, reset-marker, reappeared-current, reappeared-locked, and the retry-policy test cleanup on this branch

Validation

  • npm run lint
  • npm run typecheck
  • npm run build
  • node_modules\\.bin\\vitest.cmd run test/account-port.test.ts test/storage.test.ts test/project-migration.test.ts test/experimental-sync-target-entry.test.ts
  • npm test still hits a Vitest worker OOM on Windows in this environment after 220/221 files and 3161/3164 tests, even with NODE_OPTIONS=--max-old-space-size=8192

Risk

  • scope stays limited to export-time storage reads and related regression coverage
  • rollback is a straight revert of the PR branch commits

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

this PR fixes a class of export correctness bugs where an empty current storage pool could silently revive legacy, WAL, or reset-cleared accounts — the fix is well-scoped: loadAccountsForExport is a new side-effect-free read path (reset-marker-aware, no normalize-and-persist side effects), and migrateLegacyProjectStorageIfNeeded({ commit: false }) provides a safe in-memory-only fallback for the legacy migration path used only during export.

key changes:

  • exportAccounts now wires readCurrentStorageUnlocked (no lock, used when a transaction is active on a different path) and readCurrentStorage (withStorageLock, used when no transaction is active), replacing the previous loadAccountsInternal / withAccountStorageTransaction pairing that could return a stale snapshot from a different storage path
  • migrateLegacyProjectStorageIfNeeded gains commit: false mode that skips all persist and unlink calls, adds two TOCTOU re-read checkpoints per loop iteration (liveCurrentStorageBeforeMerge and liveCurrentStorageAfterLegacyRead), and rethrows EBUSY/EPERM/EAGAIN lock errors instead of collapsing them into an empty result — important on windows where AV or the atomic-replace pattern can hold transient locks
  • EACCES and ENOTEMPTY removed from the sync-target retryable codes set, correcting a pre-existing over-broad retry policy; EACCES now propagates fail-fast, matching the existing test for permanent permission denials
  • 657 lines of new vitest regressions cover the full scenario matrix: empty-current, reset-marker (before/during/after fallback), reappeared-current, reappeared-locked, v1-normalization-no-persist (locked and unlocked paths), legacy-without-current, and transaction-isolation

Confidence Score: 4/5

  • safe to merge; one P2 coverage gap (multi-legacy-candidate mid-loop reappear) is follow-up material, not a blocker
  • core fix is correct and well-tested across all primary scenarios; the only gap is a narrow TOCTOU edge in the multi-legacy-candidate loop that has no dedicated test but is structurally handled by the next iteration's checkpoint — prior concurrency concerns from the previous round are resolved by the commit: false path which removes all write/unlink side-effects
  • lib/storage.ts — the readLiveCurrentStorageIfExportMode closure; specifically the existsSync→return path when no legacy candidates exist or when the file appears mid-second-iteration

Important Files Changed

Filename Overview
lib/storage.ts core fix: adds loadAccountsForExport (side-effect-free, reset-marker-aware), refactors migrateLegacyProjectStorageIfNeeded with commit: false option and two TOCTOU re-read checkpoints inside the loop; the logic is sound but the double existsSync→async-read sequence in readLiveCurrentStorageIfExportMode leaves a narrow window where a reappearing file can be missed across both loop checkpoints if the timing is unlucky — covered in practice by the post-loop existsSync guard, but no explicit multi-legacy-candidate test exercises that path
lib/storage/account-port.ts clean rename: loadAccountsInternal replaced by readCurrentStorageUnlocked; the ternary now correctly routes stale-snapshot avoidance through the unlocked reader when transaction is on a different path
test/account-port.test.ts new tests cover all three routing branches (same-path snapshot reuse, different-path unlocked read, no-transaction locked read) with distinct token values — good signal coverage
test/storage.test.ts 657 lines of new regression coverage: empty-current, reset-marker-during-fallback, reappeared-current, reappeared-locked (EBUSY/EPERM/EAGAIN), no-persist-on-v1-normalization (both locked and unlocked paths), legacy-without-current, and transaction-isolation; missing: a multi-legacy-candidate scenario where the file reappears mid-second-iteration (after liveCurrentStorageAfterLegacyRead on the first candidate has already returned false)
test/experimental-sync-target-entry.test.ts retry-policy test updated to verify EACCES/ENOTEMPTY are no longer in the retryable set; new fail-fast test for EACCES correctly exercises the propagation path and verifies no sleep is called

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[exportAccounts] --> B{transaction active?}
    B -- "yes, same path" --> C[use transaction snapshot]
    B -- "yes, different path" --> D[readCurrentStorageUnlocked\nloadAccountsForExport]
    B -- no --> E[withStorageLock\nloadAccountsForExport]
    D --> F{reset marker?}
    E --> F
    F -- yes --> G[return empty intentional-reset]
    F -- no --> H[loadAccountsFromPath current]
    H -- ok --> I{reset marker?}
    I -- yes --> G
    I -- no --> J[return normalized storage]
    H -- ENOENT --> K{reset marker?}
    K -- yes --> G
    K -- no --> L[migrateLegacyProjectStorageIfNeeded\ncommit=false]
    L --> M[loop over legacy candidates]
    M --> N{TOCTOU: existsSync before merge?}
    N -- current reappeared --> O[return live storage]
    N -- not present --> P[loadNormalizedStorageFromPath legacy]
    P --> Q{TOCTOU: existsSync after legacy read?}
    Q -- current reappeared --> O
    Q -- not present --> R[mergeStorageForMigration in-memory]
    R --> S[targetStorage = merged, migrated = true]
    S --> M
    L --> T{migrated?}
    T -- yes --> U[return targetStorage]
    T -- no --> V{targetStorage AND no current file?}
    V -- yes --> U
    V -- no --> W[return null]
    O --> X{reset marker after migrate?}
    U --> X
    W --> X
    X -- yes --> G
    X -- no --> Y[exportAccountsToFile]
    Y --> Z{accounts empty?}
    Z -- yes --> AA[throw No accounts to export]
    Z -- no --> AB[write export file]
Loading

Fix All in Codex

Prompt To Fix All With AI
This is a comment left during a code review.
Path: lib/storage.ts
Line: 762-786

Comment:
**TOCTOU window after `existsSync` returns false**

when `existsSync(currentStoragePath)` returns `false` we skip reading — but on windows a concurrent writer (or AV) can create the file in the window between that sync check and the function return. in that case both `liveCurrentStorageBeforeMerge` and `liveCurrentStorageAfterLegacyRead` will still return `{ exists: false }` for that iteration and we fall through to the in-memory merge.

the post-loop `existsSync` guard (`if (targetStorage && !existsSync(currentStoragePath))`) won't save us here because `targetStorage` is null when there are no legacy candidates. in the multi-legacy-candidate case the next loop iteration's `readLiveCurrentStorageIfExportMode` would catch it, but there's no coverage for that path.

worth adding a test with two legacy candidates where the current file appears mid-second-iteration (after the first candidate's `liveCurrentStorageAfterLegacyRead` has returned false) to confirm the second iteration's `liveCurrentStorageBeforeMerge` picks it up.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (11): Last reviewed commit: "fix-export-reset-fallback" | Re-trigger Greptile

@chatgpt-codex-connector
Copy link

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 23, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

refactored export/migration wiring. added loadAccountsForExport() and changed migrateLegacyProjectStorageIfNeeded to accept options { persist?, commit? } to control persistence and unlink behavior. export callers and account export flow updated. see lib/storage.ts:line, lib/storage/account-port.ts:line, tests updated in test/*.test.ts:line.

Changes

Cohort / File(s) Summary
storage core
lib/storage.ts
added loadAccountsForExport(); changed migrateLegacyProjectStorageIfNeeded signature to accept options { persist?, commit? }. migration loop now supports in-memory-only merges when commit:false, continues to next legacy candidate on persist failure when commit:true, and only logs unlink errors that are not ENOENT. (lib/storage.ts:line)
account export flow
lib/storage/account-port.ts
renamed param loadAccountsInternalreadCurrentStorageUnlocked. when transactionState.active and transactionState.storagePath !== currentStoragePath, callers now invoke readCurrentStorageUnlocked() to fetch live storage instead of using the transaction snapshot. (lib/storage/account-port.ts:line)
export wiring
lib/storage.ts
exportAccounts now reads via readCurrentStorageUnlocked: () => loadAccountsForExport() for unlocked reads and readCurrentStorage: () => withStorageLock(() => loadAccountsForExport()) for locked reads. (lib/storage.ts:line)
tests: account-port
test/account-port.test.ts
expanded tests: preserved snapshot-only behavior when transaction path matches; added tests for mismatched transaction path calling readCurrentStorageUnlocked; added test for no active transaction using locked reader. (test/account-port.test.ts:line)
tests: storage export & locking
test/storage.test.ts
added export tests covering legacy migration during transactions, v1-on-disk normalization without persisting, and error propagation for EBUSY/EPERM. introduced setStoragePathState usage. (test/storage.test.ts:line)
tests: retry policy
test/experimental-sync-target-entry.test.ts
narrowed retryable lock codes to EBUSY, EPERM, EAGAIN; assert maxAttempts === 4 and specific sleep backoffs; added test that non-retryable lock errors (e.g., EACCES) fail fast and do not retry or normalize. (test/experimental-sync-target-entry.test.ts:line)

Sequence Diagram(s)

estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

notes and review focus

  • verify migration semantics under both commit:true and commit:false in lib/storage.ts:line. missing regression test for the intentional-reset marker behavior after migration.
  • concurrency risk: lib/storage/account-port.ts:line now calls readCurrentStorageUnlocked() when transactionState.storagePath !== currentStoragePath. confirm this does not permit double-locking, cross-context reads, or expose stale snapshots under concurrent transactions.
  • windows edge cases: test/experimental-sync-target-entry.test.ts:line narrows retryable codes and removes EACCES/ENOTEMPTY. add regression coverage for windows scenarios where EACCES might be transient and should be retried.
  • confirm persistence-failure behavior in lib/storage.ts:line is intentional: failures to persist now continue to next legacy candidate instead of aborting the migration loop.
  • ensure unlink log behavior only reports non-ENOENT errors as implemented in lib/storage.ts:line.

suggested labels

bug

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed title follows conventional commits format with type 'fix', scope 'storage', and lowercase imperative summary under 72 characters; accurately reflects the read-only export behavior change.
Description check ✅ Passed PR description is comprehensive and well-structured, covering summary, changes, validation, and risk analysis with specific file references and regression test details.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/export-empty-storage-state
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/export-empty-storage-state

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/storage.ts`:
- Around line 1475-1484: The export path currently re-throws EBUSY/EPERM from
loadAccountsFromPath (seen in the catch branch near
createEmptyStorageWithMetadata), which differs from loadAccountsInternal's
fallback behavior; add a regression test that simulates a locked storage file
(Windows-style EBUSY/EPERM) and asserts that the export code either falls back
to the backup/WAL path (matching loadAccountsInternal) or explicitly documents
and handles the error; specifically, add a test in test/account-port.test.ts (or
a new test file) that stubs/throws an error with code "EBUSY" and "EPERM" from
loadAccountsFromPath and verifies the behavior of the export routine (calling
loadAccountsForExport or the function that invokes loadAccountsFromPath) to
ensure consistent fallback behavior or explicit error handling, updating the
implementation in lib/storage.ts where necessary to mirror
loadAccountsInternal's fallback logic.

In `@test/account-port.test.ts`:
- Around line 7-81: Add a third test for exportAccountsSnapshot that passes
transactionState: undefined and stubs readCurrentStorage to return a storage
with a unique token (e.g., "locked-read-token"); assert readCurrentStorage was
called once, readCurrentStorageUnlocked was not called, and exportAccountsToFile
was invoked with storage containing that token. Locate the test by the existing
describe "account port helpers" and mimic the existing test patterns,
referencing exportAccountsSnapshot, transactionState, readCurrentStorage,
readCurrentStorageUnlocked, and exportAccountsToFile to implement the new case.

In `@test/experimental-sync-target-entry.test.ts`:
- Around line 69-73: Add deterministic regression tests that assert runtime
retry vs fail-fast behavior of readFileWithRetry rather than only checking
argument wiring: create two vitest tests in
experimental-sync-target-entry.test.ts that mock the underlying file-read to
throw an error object with errno set to a Windows lock code and use a
controllable sleep mock/timer—(1) for retryable codes (the Set used in existing
expectation), assert readFileWithRetry retries up to maxAttempts and eventually
resolves or exhausts retries (assert call count, sleeps called, and
result/throw), and (2) for fail-fast codes (EACCES and ENOTEMPTY), assert
readFileWithRetry immediately throws on first call with no sleeps or retries;
stub/spy readFileWithRetry invocation and the sleep function and use thrown
Error objects with errno properties to simulate race conditions so tests are
deterministic and bounded.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5edac6fd-b737-4226-bdf2-5f577ccc9549

📥 Commits

Reviewing files that changed from the base of the PR and between 76e5216 and cdb4a37.

📒 Files selected for processing (4)
  • lib/storage.ts
  • lib/storage/account-port.ts
  • test/account-port.test.ts
  • test/experimental-sync-target-entry.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/experimental-sync-target-entry.test.ts
  • test/account-port.test.ts
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/storage/account-port.ts
  • lib/storage.ts
🔇 Additional comments (6)
lib/storage/account-port.ts (1)

14-14: lgtm on the rename and branch logic.

the rename from loadAccountsInternal to readCurrentStorageUnlocked better communicates intent. the three-way branch correctly handles:

  • active transaction with matching path → reuse snapshot (no io)
  • active transaction with mismatched path → call unlocked loader (avoids deadlock since lock already held)
  • no active transaction → call locked reader

this aligns with the non-reentrant withStorageLock shown in lib/storage/transactions.ts:7-13.

Also applies to: 30-32

lib/storage.ts (3)

1435-1485: new export-specific loader looks correct for the stated goal.

loadAccountsForExport() intentionally avoids WAL/backup recovery, ensuring export fails if the current pool is genuinely empty. this aligns with the pr objective to prevent silent fallback to stale data during export.

key behavior differences from loadAccountsInternal:

  • no WAL journal recovery (lines 1475-1484 just re-throw)
  • no backup candidate iteration
  • no synthetic fixture promotion

1843-1844: wiring looks correct.

readCurrentStorageUnlocked bypasses the lock (caller already holds it via transaction), while readCurrentStorage wraps with withStorageLock for the no-transaction case. this matches the decision logic in lib/storage/account-port.ts:26-32.


1438-1439: missing regression test: migration writes during path-mismatch transaction.

loadAccountsForExport (lib/storage.ts:1435) calls migrateLegacyProjectStorageIfNeeded(saveAccountsUnlocked) at line 1439. when this function is used as readCurrentStorageUnlocked inside a transaction where transactionState.storagePath differs from currentStoragePath, the migration writes to the current path while the transaction holds a snapshot for a different path. test/account-port.test.ts:58 exercises the path-mismatch scenario but mocks readCurrentStorageUnlocked, so the actual migration path is never tested. add a regression test that calls exportAccountsSnapshot with a real transaction state and validates that legacy migration succeeds without corrupting the transaction snapshot.

test/account-port.test.ts (2)

8-40: test correctly verifies snapshot reuse when paths match.

assertions at lines 33-39 confirm that when transactionState.active and paths match, no loader is invoked and the snapshot is passed directly to exportAccountsToFile. deterministic, no real secrets.


42-81: test correctly verifies live storage read when paths differ.

assertions at lines 72-80 confirm readCurrentStorageUnlocked is called (not readCurrentStorage) and the live token is used, not the stale snapshot token. good coverage for the path-mismatch branch.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/storage.ts`:
- Around line 1444-1500: loadAccountsForExport calls
migrateLegacyProjectStorageIfNeeded and saveAccountsUnlocked without holding the
storage lock, allowing a race with concurrent saves; fix by performing migration
and persisting under the same locked path used for normal writes: either (A)
replace the unlocked migration+save flow with the locked
readCurrentStorage/saveAccounts flow so migrations use the storage lock, or (B)
wrap both migrateLegacyProjectStorageIfNeeded and the subsequent
saveAccountsUnlocked call in the existing storage lock helper so they run
atomically; update loadAccountsForExport to call the locked variant (or call
saveAccounts instead of saveAccountsUnlocked) and ensure
migrateLegacyProjectStorageIfNeeded persistence branch uses the lock as well, or
add a concise comment in loadAccountsForExport stating that unlocked best-effort
behavior is intentional if you choose not to lock.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ac5e8dc1-8baf-4b72-b61a-9362b220ff6b

📥 Commits

Reviewing files that changed from the base of the PR and between cdb4a37 and c95016d.

📒 Files selected for processing (4)
  • lib/storage.ts
  • test/account-port.test.ts
  • test/experimental-sync-target-entry.test.ts
  • test/storage.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/account-port.test.ts
  • test/experimental-sync-target-entry.test.ts
  • test/storage.test.ts
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/storage.ts
🔇 Additional comments (10)
test/storage.test.ts (4)

1389-1436: ebusy/eperm rethrow test addresses past review comment.

test/storage.test.ts:1389-1436 mocks loadAccountsFromPath to throw EBUSY/EPERM for the locked path, then asserts the error propagates with matching code (line 1429).

this directly covers the windows lock contention scenario flagged in the past review comment at lib/storage.ts:1490-1499. the parameterized approach ensures both codes are tested.


1301-1343: test confirms export does not persist legacy migration during transaction.

test/storage.test.ts:1301-1343 sets up a legacy storage file, invokes export within withAccountStorageTransaction, and asserts:

  • exported content matches legacy storage (line 1336)
  • currentStoragePath remains absent (line 1338)
  • legacyStoragePath is not unlinked (line 1339)

this validates the { commit: false } behavior in loadAccountsForExport({ persistMigrations: false }).


1345-1387: test confirms v3 normalization is not persisted during unlocked export read.

test/storage.test.ts:1345-1387 creates a v1 storage file, exports within a transaction, and asserts:

  • on-disk version stays 1 (line 1379)
  • exported version is 3 (line 1380)

this confirms the export path does not write back migrations when persistMigrations: false.


7-7: import added for setStoragePathState.

the import at test/storage.test.ts:7 supports the transaction-state export tests below. this is correctly scoped to lib/storage/path-state.js.

lib/storage.ts (3)

1858-1860: export wiring correctly selects unlocked vs locked reader based on transaction state.

the wiring at lib/storage.ts:1858-1860 passes loadAccountsForExport({ persistMigrations: false }) to readCurrentStorageUnlocked and wraps the locked variant in withStorageLock. this matches the logic in lib/storage/account-port.ts:26-32 where the snapshot is only reused when the transaction path matches the current storage path.

the persistMigrations: false flag ensures no side-effect writes occur during unlocked reads, which is the correct design for export under a different transaction context.


712-717: migration options refactor looks good.

the { persist, commit } options object at lib/storage.ts:712-717 cleanly separates persistence behavior from commit semantics. defaulting persist to saveAccounts and commit to true preserves backward compatibility for loadAccountsInternal.


788-830: commit flag controls whether migration writes are persisted.

the conditional at lib/storage.ts:788 correctly gates the persist/unlink logic. when commit: false, only in-memory state is updated (lines 829-830), which is appropriate for export-time migration detection without side effects.

test/experimental-sync-target-entry.test.ts (2)

43-91: test now verifies retry policy wiring and simulated retry behavior.

the updated test at test/experimental-sync-target-entry.test.ts:43-91 asserts:

  1. retryableCodes contains exactly EBUSY, EPERM, EAGAIN (lines 47-49)
  2. retryableCodes excludes EACCES, ENOTEMPTY (lines 50-51)
  3. maxAttempts is 4 (line 52)
  4. sleep is called with exponential delays 25 and 50 (lines 88-89)

this addresses the past review comment requesting runtime retry behavior verification rather than just argument wiring.


93-124: fail-fast test for non-retryable EACCES is deterministic and complete.

the new test at test/experimental-sync-target-entry.test.ts:93-124 correctly verifies:

  1. EACCES throws immediately (line 119)
  2. readFileWithRetry is called exactly once (line 121)
  3. sleep is never called (line 122)
  4. normalizeAccountStorage is never reached (line 123)

this confirms windows lock-race fail-fast policy as required by the coding guidelines.

test/account-port.test.ts (1)

8-113: all three branches of snapshot selection logic are now tested.

test/account-port.test.ts:8-113 covers:

  1. active transaction + matching path → uses snapshot (lines 33-34 assert readers not called)
  2. active transaction + mismatched path → uses readCurrentStorageUnlocked (lines 72-73)
  3. no active transaction → uses readCurrentStorage (lines 104-105)

this matches the branching logic in lib/storage/account-port.ts:26-32. the past review comment requesting the third test case is now addressed.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/storage.ts`:
- Around line 788-824: The migration (migrateLegacyProjectStorageIfNeeded) is
running too early and repopulates an empty current store before
loadAccountsForExport("locked") runs; move the call to
migrateLegacyProjectStorageIfNeeded({ persist: saveAccountsUnlocked }) out of
the immediate startup path and instead invoke it inside the ENOENT branch where
the code handles missing current storage (the branch around the enoent handling
at the earlier read logic), so migration only occurs after an export read miss;
update the call sites referenced (the current invocations near the earlier lines
calling migrateLegacyProjectStorageIfNeeded and the export call
loadAccountsForExport("locked")) and add vitest cases for “current exists + 0
accounts + legacy exists” and “reset-marker + legacy exists” to prevent
regressions and ensure concurrency/Windows IO behaviors are covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fc529852-b3fa-4ca2-ad8c-eeda31c083f6

📥 Commits

Reviewing files that changed from the base of the PR and between c95016d and 3756bd9.

📒 Files selected for processing (1)
  • lib/storage.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (1)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/storage.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
test/storage.test.ts (1)

1410-1457: 🛠️ Refactor suggestion | 🟠 Major

missing the unlocked windows-lock regression.

the unlocked happy path is covered at test/storage.test.ts:1300-1408, and this case covers the locked failure path, but nothing drives the cross-path transaction branch at lib/storage.ts:1864-1866 through an EBUSY or EPERM read failure. that is the branch most exposed to windows lock contention because it bypasses withStorageLock while another transaction owns the mutex. please add a vitest that opens an unrelated withAccountStorageTransaction(), forces loadAccountsFromPath() on the live path to throw, and asserts exportAccounts() rejects without writing exportPath.

As per coding guidelines, test/**: tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/storage.test.ts` around lines 1410 - 1457, Add a deterministic vitest
that reproduces the unlocked-windows-lock regression by starting a concurrent
withAccountStorageTransaction() to hold the in-memory transaction path, then
forcing loadAccountsFromPath() on the real storage path to throw (simulate {
code: "EBUSY" } or "EPERM") and assert that exportAccounts(exportPath) rejects
and that exportPath was not written; use vi.doMock/vi.importActual to stub
loadAccountsFromPath for the live path only, ensure you open the transaction
before calling exportAccounts, and clean up mocks/resetModules and restore
storage path with setStoragePathDirect so the test is deterministic and
exercises the cross-path branch in lib/storage.ts (the branch that bypasses
withStorageLock).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/storage.ts`:
- Around line 1453-1455: The locked-export path has a race where
saveAccountsUnlocked() can clear the reset marker and a concurrent process can
recreate it before the locked persist branches do writes/unlinks (the
migrationOptions branch and subsequent v1 normalization and legacy-migration
writes in the export flow), so change the locked export to be read-only or make
the locked persist path check the reset-marker again and refuse to write/unlink
before performing any filesystem mutation; specifically, update the code around
migrationOptions/migration commit logic and the v1-normalize and legacy-migrate
code paths to re-read the reset marker (or use a read-only export branch) before
any write/unlink, add or adjust tests in test/storage.test.ts to cover the
interleaving case, and ensure new code follows lib/** guidelines for auth
rotation, Windows IO and concurrency (including handling EBUSY/429 retries).

---

Duplicate comments:
In `@test/storage.test.ts`:
- Around line 1410-1457: Add a deterministic vitest that reproduces the
unlocked-windows-lock regression by starting a concurrent
withAccountStorageTransaction() to hold the in-memory transaction path, then
forcing loadAccountsFromPath() on the real storage path to throw (simulate {
code: "EBUSY" } or "EPERM") and assert that exportAccounts(exportPath) rejects
and that exportPath was not written; use vi.doMock/vi.importActual to stub
loadAccountsFromPath for the live path only, ensure you open the transaction
before calling exportAccounts, and clean up mocks/resetModules and restore
storage path with setStoragePathDirect so the test is deterministic and
exercises the cross-path branch in lib/storage.ts (the branch that bypasses
withStorageLock).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: dd4edecf-42c7-49b4-a27f-25b7ccbc3d52

📥 Commits

Reviewing files that changed from the base of the PR and between 3756bd9 and 05c201f.

📒 Files selected for processing (2)
  • lib/storage.ts
  • test/storage.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/storage.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/storage.test.ts

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
lib/storage.ts (1)

746-785: ⚠️ Potential issue | 🟠 Major

do not merge legacy after the live file reappears.

after the initial miss on lib/storage.ts:1455-1476, the fallback calls migrateLegacyProjectStorageIfNeeded({ commit: false }). that helper then re-reads state.currentStoragePath at lib/storage.ts:746-759 and merges it with legacy data at lib/storage.ts:781-785. if another process recreates the current file in that window—especially as an empty store—export can still return the merged legacy snapshot instead of honoring the live file. that reopens the empty-current non-revival bug under a race.

suggested fix
 		if (code === "ENOENT") {
 			const migratedLegacyStorage =
 				await migrateLegacyProjectStorageIfNeeded({ commit: false });
 			if (existsSync(resetMarkerPath)) {
 				return createEmptyStorageWithMetadata(false, "intentional-reset");
 			}
+			if (existsSync(path)) {
+				const { normalized, schemaErrors } = await loadAccountsFromPath(path, {
+					normalizeAccountStorage,
+					isRecord,
+				});
+				if (schemaErrors.length > 0) {
+					log.warn("Account storage schema validation warnings", {
+						errors: schemaErrors.slice(0, 5),
+					});
+				}
+				return normalized;
+			}
 			return migratedLegacyStorage;
 		}

please add a vitest that forces enoent on the first read and then makes the current file appear empty before the legacy merge; test/storage.test.ts:1580-1673 only covers the steady-state empty/missing cases.

As per coding guidelines, lib/**: focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios.

Also applies to: 1474-1480

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/storage.ts` around lines 746 - 785, The migration currently re-reads
state.currentStoragePath and proceeds to merge legacy data even if another
process recreated the current file in the window; to fix, in
migrateLegacyProjectStorageIfNeeded after calling loadNormalizedStorageFromPath
for state.currentStoragePath (the targetStorage read at lib/storage.ts around
the loadNormalizedStorageFromPath call) re-check that the on-disk current
storage is still "absent" or unchanged before calling
mergeStorageForMigration(targetStorage, legacyStorage,...); if the current file
now exists (or its contents/mtime indicate a non-empty live store), abort the
legacy merge and return without applying legacy data. Update logic around
loadNormalizedStorageFromPath, migrateLegacyProjectStorageIfNeeded, and the
mergeStorageForMigration call to short-circuit when the current file reappears,
and add a vitest that forces an initial ENOENT then creates an empty/current
file before the legacy merge (extend test/storage.test.ts around 1580-1673) and
verify behavior under concurrent IO (EBUSY/429) as per guidelines.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/storage.test.ts`:
- Around line 1445-1492: Update the test matrix to include the "EAGAIN" error
code as a retryable read-lock case: add "EAGAIN" to the it.each array alongside
"EBUSY" and "EPERM" (the matrix that drives the mocked loadAccountsFromPath
behavior), so the mocked loadAccountsFromPath throws an error with code "EAGAIN"
for the lockedStoragePath and the assertion on
isolatedStorageModule.exportAccounts(exportPath) expects { code: "EAGAIN" };
ensure the same addition is applied to the corresponding matrix further down
(the block covering lines 1494-1578) so both test cases exercise EAGAIN, and
keep the rest of the test flow (setStoragePathDirect, doMock/doUnmock,
resetModules) unchanged.

---

Outside diff comments:
In `@lib/storage.ts`:
- Around line 746-785: The migration currently re-reads state.currentStoragePath
and proceeds to merge legacy data even if another process recreated the current
file in the window; to fix, in migrateLegacyProjectStorageIfNeeded after calling
loadNormalizedStorageFromPath for state.currentStoragePath (the targetStorage
read at lib/storage.ts around the loadNormalizedStorageFromPath call) re-check
that the on-disk current storage is still "absent" or unchanged before calling
mergeStorageForMigration(targetStorage, legacyStorage,...); if the current file
now exists (or its contents/mtime indicate a non-empty live store), abort the
legacy merge and return without applying legacy data. Update logic around
loadNormalizedStorageFromPath, migrateLegacyProjectStorageIfNeeded, and the
mergeStorageForMigration call to short-circuit when the current file reappears,
and add a vitest that forces an initial ENOENT then creates an empty/current
file before the legacy merge (extend test/storage.test.ts around 1580-1673) and
verify behavior under concurrent IO (EBUSY/429) as per guidelines.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: ee4f670a-631d-4043-82fa-6be3b44cbef4

📥 Commits

Reviewing files that changed from the base of the PR and between 05c201f and f07b31d.

📒 Files selected for processing (2)
  • lib/storage.ts
  • test/storage.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Greptile Review
🧰 Additional context used
📓 Path-based instructions (2)
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/storage.ts
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/storage.test.ts
🔇 Additional comments (2)
lib/storage.ts (1)

1842-1843: good locked vs unlocked export reader split.

lib/storage.ts:1842-1843 cleanly separates the cross-path transaction read from the normal locked read, which keeps export side-effect free without dropping the serialized no-transaction path. test/storage.test.ts:1300-1432 and test/storage.test.ts:1494-1578 exercise both sides well.

test/storage.test.ts (1)

1580-1715: good regression coverage for legacy non-revival.

test/storage.test.ts:1580-1715 pins both cases that mattered here: empty current storage and reset-marker presence both reject export and leave legacy state untouched.

@ndycode
Copy link
Owner Author

ndycode commented Mar 23, 2026

@coderabbitai review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 23, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@ndycode
Copy link
Owner Author

ndycode commented Mar 23, 2026

@greptileai

@ndycode ndycode merged commit e3f5dfc into main Mar 23, 2026
2 checks passed
@ndycode ndycode deleted the fix/export-empty-storage-state branch March 23, 2026 12:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant